Skip to content

fix: split CORS policy across public, credentialed, and admin routes - #316

Merged
Miracle656 merged 3 commits into
Divinity-Labs:mainfrom
bamiebot-maker:fix/cors-policy-split
Oct 23, 2026
Merged

Miracle656 merged 3 commits into
Divinity-Labs:mainfrom
bamiebot-maker:fix/cors-policy-split

Conversation

@bamiebot-maker

Copy link
Copy Markdown
Contributor

Summary

Splits the blanket \origin: true\ CORS configuration into three explicit security tiers:

  1. Public Reads (\public): Stays open to any origin with explicit \credentials: false, ensuring public market rates, health checks, and discovery are world-accessible without risking ambient credential leakage.
  2. Credentialed Routes (\credentialed): Narrows policy on /usage/me\ and any requests carrying \Authorization\ / \Cookie\ headers to explicitly configured origins (\CORS_ALLOWED_ORIGINS) with explicit \credentials: true.
  3. Admin Routes (\�dmin): Closed to cross-origin browsers by default (\origin: false, \credentials: false) with optional override via \CORS_ADMIN_ALLOWED_ORIGINS\ for internal operator tools.

Also documents the full route classification matrix in \docs/routes.md\ and adds comprehensive test coverage in \src/tests/cors.test.ts\ for allowed and refused origins across all classes.

Related issue

Closes #268

Type of change

  • Bug fix
  • New feature
  • Refactor
  • Docs
  • Tests
  • CI / tooling

Checklist

  • I have read CONTRIBUTING.md

  • px tsc --noEmit\ passes

  • pm run build\ passes
  • I added / updated tests where relevant
  • I updated docs where relevant

Closes Divinity-Labs#268

- Implement dynamic CORS delegator classifying routes into public, credentialed, and admin tiers
- Keep public reads open to any origin with explicit credentials: false
- Restrict credentialed routes (/usage/me and requests carrying auth credentials) to CORS_ALLOWED_ORIGINS with credentials: true
- Close operator admin endpoints (/admin/*) against cross-origin browser requests by default
- Document route security matrix and CORS policies in docs/routes.md and README.md
- Add unit and integration tests covering allowed and refused origins on each class
@drips-wave

drips-wave Bot commented Oct 16, 2026

Copy link
Copy Markdown

@bamiebot-maker Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Miracle656

Copy link
Copy Markdown
Collaborator

One line from green. The drift guard is the only thing failing:

Found 1 environment variable(s) read in source but missing from .env.example and not in allow-list:
  - CORS_PUBLIC_BLOCKED_ORIGINS (read at src/cors.ts:120)

Add it to .env.example with a comment saying what it does and what the format is (comma-separated origins, presumably, and what an empty value means). The guard exists because a variable that only lives in someone's shell is a variable the next operator does not know to set.

Splitting CORS across public, credentialed and admin surfaces is the right shape — a single permissive policy in front of /admin/* is a real problem — so I want this. I will review it properly on the next green run.

@bamiebot-maker

Copy link
Copy Markdown
Contributor Author

Hi @Miracle656, thanks for the review! I have added \CORS_PUBLIC_BLOCKED_ORIGINS\ to .env.example\ with documentation describing its format (comma-separated origins) and behavior (empty/unset permits all public cross-origin requests). \envDrift.test.ts\ and all 20 CORS tests are now green.

@Miracle656
Miracle656 merged commit 9374b11 into Divinity-Labs:main Oct 23, 2026
@Miracle656

Copy link
Copy Markdown
Collaborator

Merged as 9374b11. The structure is right and both restricted tiers default to origin: false, so an unconfigured deployment fails closed rather than open — which is the single most important decision in a change like this and the one most often got backwards.

The best idea in the PR is classifying on the request, not only on the path:

const authHeader = headers['authorization']
const cookieHeader = headers['cookie']
if (authHeader || cookieHeader) return 'credentialed'

const requestHeaders = headers['access-control-request-headers']
if (typeof requestHeaders === 'string') {
  const lower = requestHeaders.toLowerCase()
  if (lower.includes('authorization') || lower.includes('cookie')) return 'credentialed'
}

Checking the preflight's Access-Control-Request-Headers as well as the real request is the detail that makes it work — a preflight carries no Authorization, so a path-only classifier hands the browser an open policy for a call that will arrive authenticated. Good catch.

Merged locally; one conflict in src/app.ts against the fiat routes, global rate limit and ETag registration that landed since, resolved as a union.

Four changes on top.


1. classifyRoute tested the raw path, so an admin route could be spelled into the public tier

const normalizedPath = (path || '/').split('?')[0].replace(/\/+$/, '') || '/'
if (normalizedPath === '/admin' || normalizedPath.startsWith('/admin/')) return 'admin'

req.url is the client's spelling. find-my-way percent-decodes before matching, so:

'/%61dmin/keys'.startsWith('/admin/')                  →  false
decodeURIComponent('/%61dmin/keys').startsWith('/admin/') →  true

The request reaches the admin handler and gets the public policy: origin: true, open to any site. This is the same bypass #574 fixed in the admin auth hook earlier today — worth knowing it generalises to anything that pattern-matches req.url.

Fixed by decoding until stable, so a double encoding cannot survive one pass, with a malformed escape left alone rather than throwing:

let decoded = (path || '/').split('?')[0]
for (let i = 0; i < 3; i++) {
  let next: string
  try { next = decodeURIComponent(decoded) } catch { break }
  if (next === decoded) break
  decoded = next
}

2. CORS_ALLOWED_ORIGINS=* echoed any origin back alongside credentials: true

if (norm === '*' || norm === normalizedOrigin) return true

In the credentialed tier that produces Access-Control-Allow-Origin: <whatever the caller sent> with credentials: true, which is the configuration you cannot write by accident in a static config and should not be able to write here either: any site can drive an authenticated cross-origin call with the browser's credentials attached. Browsers reject a literal * with credentials, which is exactly why echoing the origin is the dangerous form — it looks compliant and is not.

isOriginAllowed now takes allowWildcard, false for credentialed and admin, true for public reads (open to everything anyway and sending no credentials).

3. A wildcard entry ignored its own scheme

const domainPart = norm.replace(/^https?:\/\/\*\./, '').replace(/^\*\./, '')

https://*.example.com had the scheme stripped before comparison, so it matched http://evil.example.com too — an https-only allow-list that silently permits plaintext. A schemed entry now has to match the scheme; a scheme-less *.example.com still matches either, which is what writing no scheme asks for. And an unparseable Origin is refused rather than string-compared.

4. ADMIN_ROUTES, CREDENTIALED_ROUTES, PUBLIC_ROUTES were dead

Nothing in production reads them — classifyRoute works from prefixes and headers — and the only references anywhere were:

expect(ADMIN_ROUTES).toContain('/admin/keys')
expect(CREDENTIALED_ROUTES).toContain('/usage/me')
expect(PUBLIC_ROUTES).toContain('/status')

which asserts that arrays contain what they were written to contain. A hard-coded route inventory that nothing enforces is precisely the failure mode #229 just finished removing from the README: it will be wrong within a month and will read as authoritative while it is. Removed, with a note in their place saying why there is deliberately no list.

I replaced that test with four that pin the fixes above: the percent-encoded admin path (single and double encoded), the wildcard refusal per tier, scheme matching in both directions, and an unparseable origin. 23 cases in cors.test.ts now, all passing.


One thing worth a follow-up

classifyRoute derives the tier from path prefixes, but since #574 the route itself declares its tier:

{ config: { admin: true, auth: 'admin' } }   // admin routes
{ config: { public: true, auth: 'none' } }   // genuinely public

That is a better source of truth than a prefix test, for the same reason the route inventories had to go: there is no second thing to keep in sync, and a route that moves out from under /admin keeps its classification. The catch is ordering — the CORS delegator runs before req.routeOptions is reliably populated for a preflight, so it cannot simply read the config today. Worth an issue: either consult routeOptions.config when it is available and fall back to the prefix test, or register the delegator so it runs after routing. Happy to assign it to you if you want it.

129 test files / 1,553 tests pass. tsc --noEmit and npm run build clean.

Miracle656 added a commit that referenced this pull request Oct 25, 2026
The versioning design is kept as submitted: routes stay mounted once at their
bare path, `rewriteUrl` resolves `/v1/...` onto them, and the unversioned alias
answers identically with Deprecation/Link headers. Four things had to be put
back before it could land.

**The spec dropped 17 routes.** openapi.yaml is hand-maintained (only
openapi.json is generated from it), and the committed file documented 26 paths
against main's 42. /book, /correlation, /discovery/search, /dispersion (both
forms), /impact, /live, /movers, /price/{asset}/fiat, /rates, /rates/{asset},
/ready, /snapshot, /tvl, /usage, /venues and /venues/registry were documented
nowhere — not under /v1, not bare — so they would have vanished from the
published contract and from the generated client. The spec is now main's,
with every path key moved to its /v1 form and the versioning paragraph from
this branch kept. /rates was also left unversioned, which the branch's own
new coverage test forbids.

**src/app.ts reverted shipped work.** It replaced the structured logger and
genReqId with `logger: { level: 'warn' }`, dropped the setErrorHandler that
routes 5xx through sendInternalError, dropped registerETag, and replaced the
per-route CORS delegator (#268/#316) with `origin: true` — allow-all. The
route-registration imports were gone while the calls remained, which is what
CI's typecheck was failing on. app.ts is now main's, with only the three
versioning touches reapplied: the import, `rewriteUrl`, and
registerApiVersioning after compress.

`exposedHeaders: ['Deprecation', 'Link']` moved into createCorsDelegator, so
every policy branch exposes them and none can forget; without it a browser
client cannot read the deprecation warning at all.

**README lost its ENDPOINTS:END marker**, so the generated-section contract
broke. Restored from main and regenerated; the table now carries 43 versioned
rows.

**Three test files assumed bare paths.** rest-route resolves the documented URL
through rewriteVersionedApiUrl instead of looking it up in the route table
(where /v1 never appears, by design). liveSpecValidation's spec keys are
versioned, leaving the JSON-pointer assertions on the response body alone.
resolveTestRequest normalises with toBarePath, since its query-parameter and
request-body tables are keyed on the bare route — /basket, /screener, /graphql,
/verify, /settle and /webhooks silently stopped matching.

Also restored isUndocumentableOperation in the method-coverage test: the branch
removed the only call but kept the allow-list, which would have reported
QUERY /graphql as undocumented.

openapi.json, README and the TypeScript client regenerated. tsc clean, 1,690
tests pass.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

One CORS policy covers public reads and authenticated routes alike

2 participants